FlutterでRiverpodとMockitoを使ってProviderのテストしてみた
こんにちは。
モダンアプリコンサル部の坂本です。
前回の記事ではFlutterでMockitoを使用した依存関係のあるクラスの単体テストを紹介しました。
今回はRirverpod Generatorを用いて製造されたNotifierクラスについて、Providerを用いたテストを記述してみようと思います。
Riverpod Generatorの使い方などはこちらの記事を参考にしてみてください。
Riverpod Generatorに適用させる
前回の構成をRiverpodのNotifierでステート管理を行う構成に変更し、Riverpod Generatorに適用させます。
DIもRiverpodで行うよう変更しています。
part 'login_controller.g.dart'; @riverpod AuthRepository authRepository(AuthRepositoryRef ref) { return AuthRepository(); } @riverpod class LoginController extends _$LoginController { @override User? build() => null; Future login({required String loginId, required String password}) async { // LoginId、Passwordの入力チェック if (loginId.isNotEmpty && password.isNotEmpty) { state = await ref.read(authRepositoryProvider).login(loginId: loginId, password: password); } else { throw Exception("ログインID、パスワードは必須です"); } } }
上記でBuild Runnerを走らせると、login_controller.g.dart
が生成されます。
Riverpod Generatorではbuild
メソッドの戻り値に応じてNotifierを自動選択してくれますが、今回のケースではAutoDisposeNotifier
が生成されました。
テストコード
先に結果から。
記述したテストコードが以下になります。
(長いので一部省略)
abstract interface class ValueChangeListener { void call<T>(T previous, T next); } @GenerateNiceMocks([MockSpec<AuthRepository>(), MockSpec<ValueChangeListener>()]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); final authRepository = MockAuthRepository(); final listener = MockValueChangeListener(); setUp(() { reset(authRepository); reset(listener); }); ProviderContainer createContainer() => ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(authRepository), ] ); group("login", () { test("success", () async { when( authRepository.login( loginId: argThat(equals("tanaka@example.com"), named: "loginId"), password: argThat(equals("8peJuzJ*naBd"), named: "password"), ), ).thenAnswer((_) async => const User( loginId: "tanaka@example.com", name: "田中 太郎", )); final container = createContainer(); container.listen(loginControllerProvider, listener.call); final controller = container.read(loginControllerProvider.notifier); await controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd"); verify( listener.call( isNull, isA<User>() .having((p0) => p0.loginId, "loginId", "tanaka@example.com") .having((p0) => p0.name, "name", "田中 太郎"), ), ).called(1); verify( authRepository.login( loginId: "tanaka@example.com", password: "8peJuzJ*naBd", ), ).called(1); }); ... }); }
簡単な説明
前回と比較するとずいぶんと様変わりした印象です。
今回の変更によってテストの流れが以下のようになりました。
- (必要に応じて)スタブを定義
- ProviderContainerを生成(依存関係のoverride)
- Providerをテスト用のListenerで
listen
- Notifier(Controller)の処理を発火
- Listenerに期待通りの値が流れてきているかを
verify
で評価
2でProviderContainerというオブジェクトが登場した点、テスト方法として3でListenerを登録し、5で「Listenerに期待通りの値が流れてきているか」を評価している点が大きく異なります。 この2点について簡単に説明していきます。
ProviderContainer
ProviderContainer createContainer() => ProviderContainer( overrides: [ authRepositoryProvider.overrideWithValue(authRepository), ] );
ProviderContainerはProviderの状態を監視するオブジェクトです。
通常、ProviderContainerは暗黙的に生成されるため、明示的な利用をしなくても良いのですが、テストにおいてはプロバイダの状態はテストごとにリセットしたいため明示的に生成します。
また、ProviderContainerはProviderの挙動をoverrideすることができます。
ここではauthRepositoryProvider
がMockAuthRepository
を返却するようoverrideしています。
Listener
RiverpodのProviderにはListenerを通してステートの変更を監視するlisten
メソッドが用意されています。
今回のテストではこの仕組みを利用し、ValueChangeListener
というインターフェースを定義の上モックを生成し、モックの呼び出しを評価しています。
テストに用いるListenerのinterfaceです。
abstract interface class ValueChangeListener { void call<T>(T previous, T next); }
listener.call
でloginControllerProvider
のステートの変更を購読しています。
container.listen(loginControllerProvider, listener.call);
評価したい処理を発火し、listener.call
に対する呼び出しを評価しています。
await controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd"); verify( listener.call( isNull, isA<User>() .having((p0) => p0.loginId, "loginId", "tanaka@example.com") .having((p0) => p0.name, "name", "田中 太郎"), ), ).called(1);
少し回りくどい印象がありますね。
controller.state
を直接評価するのではダメなのか?という疑問が出てきます。
ここまでのケースだとそれでも評価可能ですが、以下に記述するAsyncNotifierの評価を行う場合、評価しきれない部分が出てきます。
AsyncNotifierの評価
ここまでのケースではNotifierに初期値は不要でした。
しかし、実際の開発においては初期値をWebAPIなどから取得するケースも多いのではないでしょうか。
Riverpodを用いた開発ではこういったケースの多くにはAsyncNotifierが使用されます。
class AsyncAuthRepository { Future<User?> verifyLoginUser() async { // セキュアストレージからアクセストークンとか拾って、ユーザー情報取得までやってる風 await Future.delayed(const Duration(microseconds: 500)); return const User(loginId: "sakamoto@example.com", name: "坂本 勇人"); } Future login({required String loginId, required String password}) async { // WebAPIのレスポンスを待ってる風 await Future.delayed(const Duration(microseconds: 500)); if (loginId == "sakamoto@example.com" && password == "%!vTdkQ#wJ7|") { return const User(loginId: "sakamoto@example.com", name: "坂本 勇人"); } else { throw Exception("ログインに失敗しました。"); } } }
part 'async_blog.g.dart'; @riverpod AsyncAuthRepository asyncAuthRepository(AsyncAuthRepositoryRef ref) { return AsyncAuthRepository(); } @riverpod class AsyncLoginController extends _$AsyncLoginController { @override FutureOr<User?> build() async { return await ref.read(asyncAuthRepositoryProvider).verifyLoginUser(); } Future login({required String loginId, required String password}) async { state = const AsyncValue.loading(); // AsyncValue.guardでエラーハンドリング state = await AsyncValue.guard(() async { if (state.value == null) { // LoginId、Passwordの入力チェック if (loginId.isNotEmpty && password.isNotEmpty) { return await ref.read(asyncAuthRepositoryProvider).login(loginId: loginId, password: password); } else { throw Exception("ログインID、パスワードは必須です"); } } else { // ログイン済みでログインを実施する操作ができるのはプログラムの不具合なのでErrorをthrow throw StateError("ログイン済の状態でログイン処理を実行しようとしました"); } }); } }
上記では、AsyncLoginController
のbuild
メソッドでログイン状態の確認を実施しています。
ログイン状態の確認はAsyncAuthRepository.verifyLoginUser
で非同期に行われるため、戻り値はFuture
またはFutureOr
になります。
この状態でBuild Runnerを走らせるとAsyncLoginController
はAutoDisposeAsyncNotifier
を継承します。
AutoDisposeAsyncNotifier
は遡るとAsyncNotifierBase
を継承しており、ステートはAsyncValue
でラップされ、loading、errorなどの状態を管理することになります。
このケースでのAsyncValue
の初期値はloadingになっており、処理が完了した場合やエラー発生時に状態が変化します。
そのため、今回のケースを評価しようとした場合、controller.state
のみを評価すると以下のようにloadingが正常に動作しているか評価ができない状態になります。
// ログイン済みの場合のスタブを生成 when( authRepository.verifyLoginUser(), ).thenAnswer((_) { return Future.value(const User( loginId: "tanaka@example.com", name: "田中 太郎", )); }); final container = createContainer(); // Controllerの初期化まで待機 await container.read(asyncLoginControllerProvider.future); // Controllerを取得 final controller = container.read(asyncLoginControllerProvider.notifier); // 初期化の結果は評価できるけど、途中経過が評価できない expect( controller.state, isA<AsyncData<User?>>() .having((p0) => p0.value!.loginId, "loginId", "tanaka@example.com") .having((p0) => p0.value!.name, "name", "田中 太郎"), );
このようなケースではlistenerのprevious
とnext
を評価したり、verifyInOrder
で順序を評価することで、状態の遷移を評価することができます。
変更されたlogin
メソッドへの評価を含めたものを以下に記載します。
abstract interface class ValueChangeListener { void call<T>(T previous, T next); } @GenerateNiceMocks([MockSpec<AsyncAuthRepository>(), MockSpec<ValueChangeListener>()]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); final authRepository = MockAsyncAuthRepository(); final listener = MockValueChangeListener(); setUp(() { reset(authRepository); reset(listener); }); ProviderContainer createContainer() => ProviderContainer( overrides: [ asyncAuthRepositoryProvider.overrideWithValue(authRepository), ] ); group("init", () { test("logged in", () async { // ログイン済みの場合のスタブを生成 when( authRepository.verifyLoginUser(), ).thenAnswer((_) { return Future.value(const User( loginId: "tanaka@example.com", name: "田中 太郎", )); }); final container = createContainer(); // StateをValueChangeListenerで監視 container.listen(asyncLoginControllerProvider, listener.call); // Controllerの初期化まで待機 await container.read(asyncLoginControllerProvider.future); // loading -> 田中 太郎 verify( listener.call( isA<AsyncLoading<User?>>(), isA<AsyncData<User?>>() .having((p0) => p0.value!.loginId, "loginId", "tanaka@example.com") .having((p0) => p0.value!.name, "name", "田中 太郎"), ), ); verify( authRepository.verifyLoginUser(), ).called(1); }); ... }); group("login", () { test("success", () async { // 未ログインの場合のスタブを生成 when( authRepository.verifyLoginUser(), ).thenAnswer((_) => Future.value(null)); // ログイン処理のスタブを生成 when( authRepository.login( loginId: argThat(equals("tanaka@example.com"), named: "loginId"), password: argThat(equals("8peJuzJ*naBd"), named: "password"), ), ).thenAnswer((_) async => const User( loginId: "tanaka@example.com", name: "田中 太郎", )); final container = createContainer(); // StateをValueChangeListenerで監視 container.listen(asyncLoginControllerProvider, listener.call); // Controllerの初期化完了まで待機 await container.read(asyncLoginControllerProvider.future); // 初期化処理分の呼び出しをリセット clearInteractions(authRepository); clearInteractions(listener); final controller = container.read(asyncLoginControllerProvider.notifier); await controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd"); verifyInOrder([ // null -> loading listener.call( isA<AsyncData<User?>>() .having((p0) => p0.value, "user", isNull), isA<AsyncLoading<User?>>(), ), // loading -> 田中 太郎 listener.call( isA<AsyncLoading<User?>>(), isA<AsyncData<User?>>() .having((p0) => p0.value!.loginId, "loginId", "tanaka@example.com") .having((p0) => p0.value!.name, "name", "田中 太郎"), ), ]); verify( authRepository.login( loginId: "tanaka@example.com", password: "8peJuzJ*naBd", ), ).called(1); }); ... }); }
まとめ
いかがでしたでしょうか。
RiverpodのNotifierを評価する場合、listenerを用いることで状態の遷移を評価できるため、より詳細なテストを実施できるようになりました。
初期値の存在しないNotifierであれば結果のみ評価するのでも十分なケースがありますが、手法が一貫している方が迷いがなくなると思うので、初期値の有無や非同期処理の有無に関わらずlistenerを用いた評価をしていこうと思いました。